9장. 느려도 비싸도 안 돼 (모델 빠르고 싸게 굴리기)
출처: 『AI 엔지니어링』(Chip Huyen 지음, 한국어판) | 원서: AI Engineering (O'Reilly)
코드는 분위기만 — Python·함수 이름 같은 건 몰라도 됩니다. 표의 '비유'와 '위험'만 봐도 충분해요.
이 장은 0장 용어집을 전제로 한다.
특히 추론(쓰는 단계), 지연 시간(답 나오는 시간), 프롬프트(시킬 말), RAG(자료 붙이기), 토큰(글 조각) 은 0장에 풀어 뒀다.
막히면 0장으로 돌아가면 된다.
0. 이 장의 새 단어
0장에 없는 말은 딱 3개다.
각 단어는 [한 문장 뜻 + 일상비유 + 한 줄 예] 3종으로 적었다.
처리량(throughput)
한 문장 뜻 — 1초에 답 글자(토큰)를 몇 개나 뽑아내는지를 재는 양. 많이 뽑을수록 한 번에 더 많은 손님을 받는다.
일상비유 — 식당이 1시간에 손님을 몇 명 먹이느냐. 한 명을 빨리 먹이는 것(지연 시간)과, 시간당 여러 명을 먹이는 것(처리량)은 다른 이야기다. 회전이 빠른 식당이 돈을 더 번다.
한 줄 예 —
# 1초에 몇 토큰을 뽑았나 = 처리량
made = 800 # 8초 동안 800토큰
throughput = made / 8 # 초당 100토큰
KV 캐시(KV cache)
한 문장 뜻 — 모델이 이미 계산해 둔 앞 글자들의 결과를 따로 적어 두고, 다음 글자를 만들 때 다시 안 계산하고 꺼내 쓰는 메모장.
일상비유 — 받아쓰기 메모. 긴 문장을 받아쓸 때, 앞 단어를 매번 처음부터 다시 듣지 않는다. 이미 적어 둔 걸 보고 그 뒤만 이어 쓴다. 그 메모장이 KV 캐시다.
한 줄 예 —
# 앞 글자 계산 결과를 적어 두고 다음 글자에 재사용
cache.save("앞 글자들의 계산 결과")
next_word = model.next(cache) # 처음부터 다시 안 함
양자화(quantization)
한 문장 뜻 — 모델 속 숫자를 더 거친 숫자로 바꿔, 모델을 가볍게 만들어 빠르고 싸게 굴리는 일.
일상비유 — 사진 화질 낮추기. 원본은 무겁지만, 화질을 조금 낮추면 파일이 절반으로 줄고 열기도 빨라진다. 눈으로 보기엔 거의 똑같다. 모델 숫자도 똑같이 '대충 반올림'해서 가볍게 만든다.
한 줄 예 —
# 정밀한 숫자(3.14159)를 거친 숫자(3.1)로 — 가벼워짐
weight = round(3.14159, 1) # 3.1, 자리수를 줄임
(귀납 도입) 이런 적 있죠?
답은 똑똑하게 잘 나온다.
그런데 질문 한 번에 5초가 걸린다.
사용자는 화면만 보다가 떠난다.
게다가 사람이 늘자, 월말 청구서가 폭발했다.
모델은 한 번 굴릴 때마다(추론) 시간과 돈을 먹는다.
이걸 몰랐던 것이다.
좋은 답인데 아무도 안 쓴다.
너무 느리고 너무 비싸서다.
여기서 '느림'과 '비쌈'을 줄이는 일이 이 장의 전부다.
이걸 추론 최적화 라고 부른다.
# 답은 좋은데 한 번에 5초 + 매번 돈
answer = model.run(prompt) # 5초 걸림, 한 번에 0.1달러
# 5초 → 0.5초, 0.1달러 → 0.01달러로 줄이는 게 이 장
한 문장 정의 — 추론 최적화는 답 품질은 그대로 둔 채, 느림(지연 시간)과 비쌈(비용)만 줄이는 일이다.
이 장에서 딱 4가지만
이 장에서 딱 4가지만
- 빠르냐는 두 개로 쪼개 잰다. 첫 글자 나올 때까지(TTFT)와, 그 뒤 글자가 줄줄 나오는 속도(TPOT).
- 모델을 가볍게. 숫자를 거칠게 바꾸면(양자화) 빨라지고 싸진다.
- 두 번 계산하지 않기. 앞 글자 결과를 메모(KV 캐시)해 두고 재사용한다.
- 손님을 모아서 한 번에. 여러 질문을 묶어 처리하면(배치) 시간당 처리량이 크게 는다.
각 개념은 아래에서 하나씩 본다.
개념 1 — 빠름은 두 개로 쪼개 잰다 (TTFT와 TPOT)
망가지는 장면
"우리 모델 빠른가요?" 물으면 다들 "느려요" 라고만 한다.
근데 어디가 느린지를 모른다.
첫 글자가 늦게 뜨는 건지, 글자가 뜬 뒤 줄줄 안 나오는 건지 구분을 안 했다.
고칠 데를 못 찾는다.
일상비유
식당에서 음식이 늦다고 느끼는 데는 두 가지가 있다.
하나는 주문하고 첫 접시가 나올 때까지의 시간이다.
다른 하나는 첫 접시 뒤로 나머지가 줄줄 나오는 속도다.
둘은 원인도 다르고 고치는 법도 다르다.
| 비유 | 코드 | 위험 |
|---|---|---|
| 첫 접시까지 걸린 시간 (TTFT) | 첫 글자까지의 시간 |
길면 사용자가 첫 글자 보기 전에 떠남 |
| 첫 접시 뒤 나오는 속도 (TPOT) | 글자 한 개당 걸리는 시간 |
느리면 답이 뚝뚝 끊겨 답답함 |
한 문장 정의 — 지연 시간은 첫 글자까지의 시간(TTFT) 과 그 뒤 글자당 시간(TPOT) 두 개로 쪼개 재며, 어느 쪽이 느린지를 알아야 고칠 데가 보인다.
가장 단순한 규칙 — 느리다는 말이 나오면, 먼저 TTFT인지 TPOT인지부터 가른다.
한 걸음 더 ▸ (지금 몰라도 됨)
시간은 평균으로 보면 속는다. 가끔 한 번 10초 걸린 게 평균을 망친다. 그래서 "느린 순으로 줄 세워 위에서 10%"를 따로 본다. 지금은 "평균만 보면 안 된다" 만 들고 가면 된다.
예시 폭격
예시 1 (worked-example — 완성 예).
전체 답이 나오기까지의 시간을 두 조각으로 나눠 본다.
ttft = 1.0 # 첫 글자까지 1초
tpot = 0.05 # 그 뒤 글자 한 개당 0.05초
n = 100 # 글자 100개
total = ttft + tpot * n # 1.0 + 5.0 = 6.0초
전체 6초 중, 첫 글자에 1초, 나머지에 5초가 들었다.
예시 2 (before / after).
before — "그냥 느려요" (어디가 느린지 모름, 고칠 수 없음)
after — "TTFT 3초가 길어요" (첫 글자가 늦음 → 첫 글자 쪽을 고치면 됨)
예시 3 (부분 완성 — 빈칸).
같은 답을 첫 글자만 빨리 띄우게 바꿨다. 빈칸을 채워 보자.
ttft = 0.5 # 첫 글자까지 0.5초로 줄임
tpot = 0.05
n = 100
total = ____ + ____ * ____ # 빈칸: ttft, tpot, n
# 답: 0.5 + 0.05 * 100 = 5.5초
예시 4 (독립 적용).
TTFT 2초, TPOT 0.1초, 글자 50개일 때 전체 시간을 직접 구해 보자.
(직접 계산 → 2 + 0.1 × 50 = 7초.)
미니 시나리오
채팅 봇이 "답답하다" 는 항의를 받았다.
재 보니 첫 글자는 0.3초로 빨랐다.
그런데 그 뒤 글자가 한 개당 0.5초로 뚝뚝 끊겼다.
문제는 TTFT가 아니라 TPOT였다.
글자 나오는 속도 쪽을 손봐야 한다.
개념 2 — 모델을 가볍게 (양자화)
망가지는 장면
모델이 너무 무거워서 비싼 장비를 꽉 채운다.
한 번 답하는 데 메모리에서 무거운 숫자를 잔뜩 퍼 올려야 한다.
그래서 느리고, 장비값도 많이 든다.
품질은 좋은데 이걸론 못 팔겠다.
일상비유
고화질 사진을 메신저로 보낸다.
원본은 너무 커서 올라가지도, 받기도 느리다.
화질을 한 단계 낮추면 크기가 절반으로 줄고, 보기에도 거의 똑같다.
모델 속 숫자도 똑같이 '대충 반올림'해서 가볍게 만든다.
이게 양자화다.
| 비유 | 코드 | 위험 |
|---|---|---|
| 화질 낮춰 절반 크기 (양자화함) | round(3.14159, 1) → 3.1 |
거의 안전 — 너무 거칠게 줄이면 품질이 살짝 떨어짐 |
| 원본 그대로 둠 (안 줄임) | keep(3.14159265) |
무겁고 느리고 비쌈 |
한 문장 정의 — 양자화는 모델 속 숫자를 더 거친 숫자로 반올림 해 모델을 가볍게 만들어, 빠르고 싸게 굴리는 일이다.
가장 단순한 규칙 — 무거워서 느리면, 먼저 숫자를 한 단계 거칠게(양자화) 줄여 본다. 품질이 견디면 그대로 둔다.
한 걸음 더 ▸ (지금 몰라도 됨)
아무리 줄여도 한계가 있다. 숫자를 너무 거칠게 만들면 답이 망가진다. 지금은 "조금 줄이면 거의 공짜로 빨라진다, 단 끝까지는 못 줄인다" 만 들고 가면 된다.
예시 폭격
예시 1 (worked-example — 완성 예).
무거운 숫자를 거친 숫자로 바꿔 크기를 절반으로 줄인다.
heavy = 3.14159265 # 무거운(정밀한) 숫자
light = round(heavy, 1) # 3.1, 거칠게 — 메모리 절반
값은 거의 같고, 메모리는 절반이 됐다.
예시 2 (before / after).
before — 정밀한 숫자 그대로. 무겁고 느림.
after — 거친 숫자로 반올림. 가볍고 빠름. 답은 거의 같음.
before = 3.14159265 # 무거움
after = 3.1 # 가벼움, 결과는 거의 동일
예시 3 (부분 완성 — 빈칸).
숫자 목록을 통째로 한 자리로 줄여 보자. 빈칸을 채운다.
heavy = [3.14159, 2.71828, 1.61803]
light = [round(x, ____) for x in heavy] # 빈칸: 1
# 답: [3.1, 2.7, 1.6]
예시 4 (독립 적용).
0.57721 을 소수 둘째 자리로 거칠게 줄이면 얼마일까.
(직접 계산 → round(0.57721, 2) → 0.58.)
미니 시나리오
모델이 무거워 장비 한 대에 겨우 들어간다.
숫자를 한 단계 거칠게 줄였다(양자화).
크기가 절반이 됐다.
같은 장비에 두 배로 올라가고, 속도도 빨라졌다.
답을 몇 개 비교해 보니 품질은 거의 그대로였다.
개념 3 — 두 번 계산하지 않기 (KV 캐시)
망가지는 장면
모델이 답 글자를 하나씩 만든다.
그런데 새 글자를 만들 때마다, 앞에 쓴 글자 전부를 처음부터 다시 계산한다.
답이 길어질수록 같은 계산을 반복하고 또 반복한다.
쓸데없이 느리다.
일상비유
긴 문장을 받아쓰는 사람을 떠올린다.
새 단어를 적을 때마다 첫 단어부터 다시 듣는 사람은 없다.
이미 적어 둔 메모를 보고, 그 뒤만 이어 쓴다.
그 '적어 둔 메모'가 KV 캐시다.
| 비유 | 코드 | 위험 |
|---|---|---|
| 앞 단어는 메모 보고 넘김 (캐시 씀) | cache.save(앞 결과) 후 재사용 |
안전·빠름 — 단 메모가 길어지면 메모장 자리가 큼 |
| 매번 처음부터 다시 들음 (캐시 안 씀) | recompute(처음부터) |
답이 길수록 느려짐 — 같은 계산 반복 |
한 문장 정의 — KV 캐시는 앞 글자의 계산 결과를 메모해 두고 다음 글자에서 재사용 해, 같은 계산을 두 번 하지 않게 만드는 메모장이다.
가장 단순한 규칙 — 답을 한 글자씩 길게 만들 땐, 앞 결과를 메모(KV 캐시)해 두고 다시 계산하지 않는다.
한 걸음 더 ▸ (지금 몰라도 됨)
메모장도 공짜는 아니다. 답이 아주 길어지면 메모 자체가 자리를 많이 먹는다. 그래서 메모를 더 알뜰하게 쓰는 방법들이 따로 있다. 지금은 "두 번 계산 안 하려고 적어 둔다, 단 적은 게 너무 많아지면 그것도 짐" 만 들고 가면 된다.
예시 폭격
예시 1 (worked-example — 완성 예).
앞 글자 결과를 메모에 쌓아 두고, 다음 글자는 그 뒤만 만든다.
cache = [] # 메모장
cache.append("첫 글자 결과") # 적어 둠
cache.append("둘째 글자 결과") # 적어 둠
# 셋째 글자는 cache를 보고 그 뒤만 계산 — 앞은 다시 안 함
예시 2 (before / after).
before — 글자마다 처음부터 다시. 글자 4개면 1+2+3+4 = 10번 계산.
after — 앞은 메모에서 꺼냄. 글자 4개면 1+1+1+1 = 4번 계산.
# before: 매번 처음부터
total_before = 1 + 2 + 3 + 4 # 10번
# after: 앞은 캐시
total_after = 1 + 1 + 1 + 1 # 4번
예시 3 (부분 완성 — 빈칸).
메모장에 새 결과를 적는 줄을 채워 보자.
cache = []
cache.____("새 글자 결과") # 빈칸: append
# 답: cache.append("새 글자 결과")
예시 4 (독립 적용).
글자 6개를 만들 때, 캐시를 쓰면 계산이 총 몇 번일까.
(직접 계산 → 글자마다 1번씩 → 6번.)
미니 시나리오
긴 답을 만드는데 끝으로 갈수록 점점 느려졌다.
이유는 글자마다 앞 전부를 다시 계산해서였다.
앞 결과를 메모(KV 캐시)에 쌓고 재사용하게 바꿨다.
답이 길어져도 속도가 일정해졌다.
개념 4 — 손님을 모아서 한 번에 (배치)
망가지는 장면
질문이 들어올 때마다 한 건씩 처리한다.
장비는 한 번에 여러 건을 처리할 수 있는데, 한 건만 시키니 자리가 텅텅 빈다.
손님은 줄 서 있는데 식당은 한 명씩만 받는다.
시간당 받는 손님 수(처리량)가 형편없다.
일상비유
버스를 떠올린다.
손님 한 명 올 때마다 버스 한 대를 보내면 낭비다.
자리가 찰 때까지 모았다가 한 번에 태워 보낸다.
질문도 여러 개를 모아 한 번에 처리하면, 같은 시간에 훨씬 많이 처리한다.
| 비유 | 코드 | 위험 |
|---|---|---|
| 여러 명 모아 한 버스 (배치) | model.run([q1, q2, q3]) |
처리량↑ — 단 모으느라 첫 손님은 살짝 기다림 |
| 한 명당 버스 한 대 (한 건씩) | model.run(q1) 따로 또 따로 |
자리 텅 빔 — 시간당 적게 처리, 비쌈 |
한 문장 정의 — 배치는 여러 질문을 한 묶음으로 모아 한 번에 처리 해, 같은 시간에 더 많이 처리하고(처리량↑) 한 건당 비용을 낮추는 방식이다.
가장 단순한 규칙 — 질문이 많을 땐, 한 건씩 말고 묶어서(배치) 한 번에 굴린다.
한 걸음 더 ▸ (지금 몰라도 됨)
무작정 오래 모으면 첫 손님이 너무 오래 기다린다. 그래서 "끝난 자리에 바로 새 손님을 채우는" 더 똑똑한 방식이 있다. 지금은 "묶으면 처리량이 크게 는다, 단 너무 오래 모으면 첫 손님이 답답해진다" 만 들고 가면 된다.
예시 폭격
예시 1 (worked-example — 완성 예).
세 질문을 한 묶음으로 모아 한 번에 굴린다.
batch = ["질문1", "질문2", "질문3"] # 한 버스에 셋
answers = model.run(batch) # 한 번에 처리
한 번 굴려서 답 셋을 받았다.
예시 2 (before / after).
before — 한 건씩 세 번 굴림. 자리 텅 빔. 느리고 비쌈.
after — 셋을 묶어 한 번 굴림. 자리 꽉 참. 시간당 더 많이 처리.
# before: 따로따로 세 번
a1 = model.run("질문1")
a2 = model.run("질문2")
a3 = model.run("질문3")
# after: 한 번에
answers = model.run(["질문1", "질문2", "질문3"])
예시 3 (부분 완성 — 빈칸).
대기 줄에 있는 질문들을 한 묶음으로 만들어 보자.
waiting = ["질문1", "질문2", "질문3", "질문4"]
answers = model.run(____) # 빈칸: waiting
# 답: model.run(waiting)
예시 4 (독립 적용).
질문 10개를 한 건씩 굴리면 10번, 묶어서 굴리면 몇 번 굴릴까.
(직접 생각 → 한 묶음이면 1번.)
미니 시나리오
요청이 몰리는데 한 건씩 처리하니 줄이 길어졌다.
장비는 여유가 있는데도 한 건만 시키고 있었다.
대기 중인 질문을 묶어서(배치) 한 번에 굴렸다.
같은 장비로 시간당 처리량이 두세 배가 됐다.
청구서도 그만큼 줄었다.
가장 단순한 행동 규칙
느리거나 비싸면, 이 순서로 손댄다.
-
먼저 어디가 느린지 가른다. 첫 글자(TTFT)냐, 그 뒤 속도(TPOT)냐.
-
무거워서 느리면, 숫자를 한 단계 거칠게 줄인다(양자화).
-
답을 길게 만들 땐, 앞 결과를 메모해 두 번 계산을 막는다(KV 캐시).
-
질문이 많으면, 묶어서 한 번에 굴린다(배치).
쉬운 것부터, 효과 큰 것부터다.
정리
빠름은 두 개로 쪼개 잰다 — 첫 글자(TTFT)와 그 뒤 속도(TPOT).
가볍게(양자화)·두 번 계산 안 하기(KV 캐시)·묶어 굴리기(배치)로 느림과 비쌈을 줄인다.
품질은 그대로 두고, 느림과 비쌈만 깎는 게 추론 최적화다.
다음 장 예고 1줄. (지금 몰라도 됩니다 — 다음 장에서 이 모든 조각을 하나의 제품으로 합치는 이야기를 합니다.)
클릭하거나 Space를 눌러 뒤집기